iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護系列 第 24 篇

Day 24|Use Case 直接依賴 DbContext,架構測試為什麼還是綠燈?用 Dependency Rule 找出掃描盲點

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天剛把 EF Core 與通知 SDK 留在 Use Case 外圍,今天我就故意把 WorkItemsDbContext 加回 OverdueWorkItemProcessor 的建構式。Release Build 通過,既有的 ProductionBoundaryTests 也全部通過。

Code 已經違反 Dependency Rule,測試卻還是綠的。

測試沒有判斷錯,它根本沒看到這個 Processor。

原本的 Architecture Test 只掃描 src/WorkItems.Api/UseCases,OverdueWorkItemProcessor.cs 卻位於 API 根目錄。保留同一個 DbContext 違規、補完整 Discovery 後,測試立即轉紅;移除依賴、讓 Processor 只認識 Use Case Port 後,才重新得到有意義的綠燈。

這次最後接受 Source/Namespace Test,不是因為它能保證整個架構永遠正確,而是目前高階型別不多,先用明確的所有權、非空掃描與受控違規,就能攔住已知風險。等高階政策持續增加、多人維護或越界反覆發生,再升級成獨立 Assembly。

今天要檢查的因此不是測試名稱寫得多完整,而是它實際保護哪些 Code。Agent 建立 Interface 或 Architecture Test 後,如果受測集合根本漏掉高階政策,最後的全綠摘要仍然沒有驗收價值。

整篇可以先抓住一條「假綠燈 → 真紅燈 → 真綠燈」的路徑:

階段 同一個 DbContext 違規發生什麼事 這一步要證明什麼
假綠燈 Processor 直接依賴 DbContext,舊測試仍通過 問題出在受保護型別的發現範圍,不是違規突然合理
真紅燈 保留相同違規,只補完整 Discovery 與非空檢查 Architecture Test 確實看得見並拒絕這項依賴
真綠燈 規則不變,Processor 改依賴 Use Case 擁有的 Port 結構規則與既有 API/Outbox 行為能同時通過

這樣設計,是為了避免「修完後測試全綠」變成循環論證。規則必須先對同一項錯誤亮紅燈,才有資格替修正後的綠燈背書。

控制流可以往外,原始碼依賴仍要朝高階政策靠攏

先用 Work Item API 把四個容易混在一起的概念分開:

名詞 它回答的問題 Work Item API 中的例子
執行時控制流 系統執行時,呼叫會走到哪裡? Processor 呼叫 Persistence Port,最後由 EF Core Adapter 存進 SQLite
原始碼依賴 編譯某個類別時,它必須認識哪些型別? Processor 若直接接收 WorkItemsDbContext,就必須認識外層 Persistence 型別
Composition Root Interface 與具體實作在哪裡完成組裝? Program.cs 把 Use Case Port 綁定到 EF Core 或 SDK Adapter
Dependency Rule 哪一層可以在原始碼中認識哪一層? 外層 Adapter 可以認識 Use Case Port;Use Case 不引用 EF Core、MVC 或 SDK 型別

呼叫最後會抵達資料庫,不代表 Use Case 必須引用 DbContext。Use Case 定義自己需要的 Port,外層 Adapter 實作,Program.cs 再完成組裝。控制流可以往外,原始碼依賴仍指向高階政策。

在這個 API 裡,業務規則、Use Case、Adapter 與外部 Framework 各自負責什麼?

Clean Architecture 經常畫成四個同心圓:Entities、Use Cases、Interface Adapters,以及 Frameworks and Drivers。這些圓圈是在區分責任與政策層次,不是要求每個專案固定拆成四個資料夾或四個 Assembly。

區域 負責什麼 Work Item API 中的例子
Entities 最穩定、最通用的核心業務規則 WorkItems.ServiceLevel 內的服務等級政策
Use Cases 編排某個應用程式情境與所需能力 OverdueWorkItemProcessor、通知與 Persistence Port
Interface Adapters 翻譯核心與外部技術之間的資料與呼叫 Controller、EF Core Adapter、通知 SDK Adapter
Frameworks and Drivers Web Framework、資料庫與第三方工具 ASP.NET Core、EF Core、SQLite、通知 SDK

別被 WorkItem 的 Entity 名稱誤導。它目前負責 EF Core Persistence Mapping 與 Change Tracking,因此仍是外層資料實作;類別名稱叫 Entity,不會自動讓它成為最內圈的核心規則。

判斷角色要看它承擔什麼責任。更換資料庫或 SDK 時,Use Case 是否也被迫修改,才是今天真正要檢查的問題。

文字規則擋不住 DbContext 越界,還需要能拒絕錯誤依賴的檢查

Uncle Bob 在近期訪談裡分享,他會讓 Agent 產生 Architecture Viewer,把模組與依賴方向攤開,再由確定性檢查工具驗證哪些依賴允許存在。Agent 若建立被禁止的依賴,就得反轉依賴、加入合適介面,或重新拆分模組。

這比在 Prompt 裡寫「請遵守 Clean Architecture」多了一道真正的拒絕機制。文字說明高階政策為什麼不能認識 EF Core;Dependency Checker 則確認最後的 Code 有沒有引用 DbContext。

但工具只會檢查已列入規則與 Discovery 範圍的型別。高階政策若被放在測試找不到的位置,錯誤依賴仍可能維持綠燈。

今天讓同一個 DbContext 違規先漏過、再轉紅,就是要確認 Architecture Test 到底看見哪些型別,而不是只看測試名稱聽起來是否完整。

執行流程會走向資料庫,為什麼 DbContext 型別不能進入 Use Case?

先看執行時方向。HTTP Request 或排程觸發逾期流程後,Use Case 必須存取資料,也必須把通知交給 Provider:

flowchart LR
    HTTP["HTTP Controller"] --> InputPort["IOverdueWorkItemProcessor"]
    Worker["Background Worker"] --> InputPort
    InputPort --> UseCase["OverdueWorkItemProcessor"]
    UseCase --> PersistencePort["Persistence Ports"]
    UseCase --> NotificationPort["Notification Port"]
    PersistencePort --> EFAdapter["EF Core Adapters"]
    EFAdapter --> Database["SQLite"]
    NotificationPort --> NotificationAdapter["Notification Adapter"]
    NotificationAdapter --> SDK["External SDK/Provider"]

這張圖回答執行時誰呼叫誰:Use Case 會透過 Port 走到 EF Core 與通知 SDK。

原始碼依賴問的是另一件事:編譯 Processor 時,它必須認識哪些型別?

flowchart TB
    Controller["Controller/Worker"] --> InputPort["Use Case Input Port"]
    EFAdapter["EF Core Adapters"] --> PersistencePort["Use Case 擁有的 Persistence Ports"]
    NotificationAdapter["Notification Adapter"] --> NotificationPort["Use Case 擁有的 Notification Port"]
    EFAdapter --> EFCore["EF Core/SQLite"]
    NotificationAdapter --> SDK["External SDK"]
    CompositionRoot["Program.cs<br/>Composition Root"] --> InputPort
    CompositionRoot --> EFAdapter
    CompositionRoot --> NotificationAdapter

外層 Adapter 實作 Use Case 擁有的 Interface,因此原始碼依賴往內指。OverdueWorkItemProcessor 只看得到 IOverdueWorkItemMarker 與 INotificationOutboxDispatcher;實際傳送通知的 IWorkItemNotificationSender 留在 Dispatcher 後方,EF Core 與 SDK 型別都在外圍。

兩邊的綁定集中在 Program.cs:

builder.Services.AddScoped<
    INotificationOutboxDispatcher,
    EfCoreNotificationOutboxDispatcher>();
builder.Services.AddScoped<
    IOverdueWorkItemMarker,
    EfCoreOverdueWorkItemMarker>();
builder.Services.AddScoped<
    IOverdueWorkItemProcessor,
    OverdueWorkItemProcessor>();

執行時,Use Case 會呼叫外層實作;編譯時,外層實作則引用 Use Case 擁有的 Interface。Adapter 可以引用 EF Core 或 SDK,只要引用沒有回流到高階政策。

Interface 本身不會自動反轉依賴。假設 IOverdueWorkItemMarker 定義在 Infrastructure Project,Processor 為了使用它,仍得參考 Infrastructure。Port 必須由需要這項能力的 Use Case 擁有,外層 Adapter 再反過來實作,依賴方向才真的往內。

Boundary Test 只掃描 UseCases 資料夾,因此漏掉根目錄的 Processor

本文的 Boundary Test 指 ProductionBoundaryTests。它讀取 Production Source 與 Project Reference,檢查架構依賴;它不送 HTTP Request,也不負責驗證 API 回應內容。

實驗從昨天的接受版本開始:

  • Commit:3630a0adcd0de17c70b5740948dbb2b49f26dc47
  • Annotated Tag:day-23-clean-boundaries
  • 已知狀態:外部行為維持穩定,既有邊界測試沒有回報違規。

你可以直接切到相同起點:

git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git fetch --tags
git switch --detach day-23-clean-boundaries

昨天新增的 Boundary Test 看起來已經禁止 Use Case 引用 EF Core、WorkItemsDbContext、EF Entity 與通知 SDK。可是它選擇檔案的方式,簡化後是:

var useCasesPath = Path.Combine(
    repositoryRoot,
    "src",
    "WorkItems.Api",
    "UseCases");

var useCaseFiles = Directory.GetFiles(
    useCasesPath,
    "*.cs",
    SearchOption.AllDirectories);

問題不在禁止清單,而在受測集合。OverdueWorkItemProcessor.cs 負責協調整個逾期流程,卻放在 src/WorkItems.Api 根目錄,不在 UseCases 資料夾內。測試把資料夾位置當成高階政策的完整清單,因此漏掉真正的流程協調器。

src/WorkItems.Api/
├─ UseCases/                         ← Boundary Test 只掃這裡
│  └─ ...
└─ OverdueWorkItemProcessor.cs       ← 實際流程協調器,沒有被掃到

依賴掃描至少要交代三件事:

  1. Discovery:找出所有應受保護的高階政策。
  2. Rule:對這些型別套用禁止依賴,例如不得引用 EF Core、MVC 或 SDK。
  3. Sanity Check:確認 Discovery 至少找到目標,避免掃描零個檔案仍顯示綠燈。

原本測試只有 Rule,Discovery 又依賴不完整的資料夾位置。禁止清單寫得再完整,也碰不到根目錄的 Processor。

同一個 DbContext 違規先漏過、再被抓到,最後才移除:三階段實驗怎麼設計?

如果只展示修正後綠燈,讀者只能知道最後通過,無法確認規則是否真的具備辨錯能力。因此我固定同一個 DbContext 違規,先讓舊規則漏過,再補強 Discovery 取得紅燈,最後才移除違規。這種方式稱為受控違規(Controlled Violation)。

階段 保持不變 刻意改變 要回答的問題
假綠燈 DbContext 違規與既有行為 不修改舊測試 舊測試真的看得到違規嗎?
真紅燈 保留相同違規 補完整發現範圍 新規則能不能指出同一項違規?
真綠燈 新規則保持不變 移除外層依賴 修正後能否同時通過結構與行為驗證?

模型固定使用 Codex GPT-5.6-SOL-HIGH,並在單一隔離 Worktree 內執行。Dependency Rule、受控違規與三階段順序由 User 事先定義。Agent 依序重現盲點、補出紅燈,再完成修正;本次沒有測試 Agent 能否自行診斷整套架構。

你正在 AI-CleanCode-API-Demo 的隔離 Worktree 中執行正式實驗。

固定實驗條件:
- 起始 Commit 必須是 3630a0adcd0de17c70b5740948dbb2b49f26dc47。
- 模型固定為 gpt-5.6-sol,Reasoning effort 固定為 high。
- 先閱讀 AGENTS.md、Solution、Production Code 與既有測試,再修改。
- 不得修改 HTTP Route、Method、Status Code、Request/Response JSON、
  SQLite Schema、Outbox、Retry、Cancellation、lost ACK、並行 Claim
  或 Idempotency Key 語意。
- 不得新增 NuGet 套件、Migration、外部網路呼叫、微服務或新部署 Artifact。
- 必須依序保存假綠燈、紅燈與修正後綠燈,不能直接跳到最終答案。
- 不要 Commit 或建立 Tag,由主流程複驗後決定接受版本。

Repository 上下文與待驗證假設:
- Day 23 已將 EF Core 與通知 SDK 留在外層 Adapter。
- 現有 ProductionBoundaryTests 只掃描 src/WorkItems.Api/UseCases。
- OverdueWorkItemProcessor.cs 位於該目錄之外。
- 待驗證假設是:Processor 直接接收 WorkItemsDbContext 後,
  現有 Architecture Test 仍可能通過。

第一階段:重現假綠燈
1. 在不改變外部行為的前提下,刻意讓 Processor 直接引用並接收 DbContext。
2. 不要先修改 ProductionBoundaryTests。
3. 執行既有 Production Boundary Test 與必要 Build。
4. 若測試仍通過,記錄掃描範圍造成的假綠燈;若失敗,依證據調整推論。

第二階段:讓同一個違規轉成紅燈
1. 重新盤點真正負責應用程式流程與高階政策的型別。
2. 補強 Architecture Test 或調整高階政策的 Namespace/位置。
3. 暫時保留 DbContext 直接依賴。
4. 目標測試必須留下能指出外層技術引用的紅燈。

第三階段:修正為真正的綠燈
1. 移除 Processor 對 DbContext 的直接依賴。
2. Processor 只依賴高階政策需要的 Port;跨邊界資料由 Use Case 擁有。
3. EF Core、SDK 與 Adapter 留在外層,由 Program.cs 組裝。
4. 不得把 Clean Architecture 四個圓圈照抄成固定 Project 範本。
5. Architecture Test 要同時保護協調器、Port 與跨邊界資料。

必須說明:
- 執行時控制流與原始碼依賴方向。
- 外層 Adapter 為何能在執行時被呼叫,高階政策卻不引用它。
- Architecture Test 能證明與不能證明的範圍。
- 何時使用 Source/Namespace Test,何時升級成獨立 Assembly。
- 本次沒有新增的架構與停止理由。

完成後執行 Locked Restore、Release Build、完整測試、Format、
套件弱點掃描與系列 Smoke,並回報修改、依賴方向、跨界資料、
Composition Root、完整 Gate 與 Architecture Test 的盲點。

逐字 Prompt、Session 原始輸出與重跑資訊,都放在文章最後的公開實驗紀錄中。

第一階段:Processor 已依賴 DbContext,既有邊界測試仍然通過

Agent 先加入一個只為重現問題而存在的受控違規:

// 受控違規:只用於重現 Architecture Test 的掃描盲點。
public sealed class OverdueWorkItemProcessor(
    WorkItemsDbContext database,
    IOverdueWorkItemMarker overdueWorkItemMarker,
    INotificationOutboxDispatcher notificationOutboxDispatcher)
    : IOverdueWorkItemProcessor
{
    // 原有執行流程保持不變。
}

database 雖然沒有被使用,建構式一加上去,OverdueWorkItemProcessor 就必須引用 WorkItemsDbContext 與 WorkItems.Api.Data。未來 Persistence 型別改名、搬移或替換時,高階協調器也會被迫修改。

這個參數刻意不參與執行流程。Dependency Rule 檢查的是原始碼依賴,只要建構式出現 WorkItemsDbContext,編譯時耦合就已成立。若一定要等 Processor 實際呼叫 database.WorkItems 才算違規,就混淆了結構與行為兩種問題。

執行結果如下:

觀察項目 實驗結果
Production Code Processor 可以正常編譯
既有邊界檢查 沒有回報任何違規
實際原始碼依賴 Processor 已直接認識 WorkItemsDbContext

這就是假綠燈:Build 通過,名稱叫 ProductionBoundaryTests 的測試也通過,但受測集合根本沒有包含 Processor。只看最後一行摘要,很容易把「沒有被檢查」誤讀成「沒有違規」。

掃描範圍過窄造成架構測試假綠燈

圖:違規 Processor 位於掃描範圍外;測試綠燈只代表 UseCases 資料夾內沒有被發現的違規。

第二階段:把 Processor 納入受保護集合後,同一個 DbContext 依賴轉為紅燈

我先保留 DbContext 違規,回頭定義哪些型別應該受到 Dependency Rule 保護。

Agent 把 Processor、Input Port 與處理摘要放入:

WorkItems.Api.UseCases.OverdueProcessing

接著把檢查改成兩層:

  1. 列出目前已知的高階政策型別,確認它們都由 WorkItems.Api.UseCases.* Namespace 擁有。
  2. 掃描 API 內所有 Production .cs 檔,再依 Namespace 選出 Use Case Source,禁止它引用外層技術。
var highLevelPolicySources = Directory
    .GetFiles(productionPath, "*.cs", SearchOption.AllDirectories)
    .Select(path => new
    {
        Path = path,
        Source = File.ReadAllText(path)
    })
    .Where(file => UseCasesNamespacePattern().IsMatch(file.Source))
    .ToArray();

Assert.True(
    highLevelPolicySources.Length > 0,
    "Architecture Test 設定錯誤:沒有找到 Use Case Production Source。");

Length > 0 是測試本身的防呆。路徑或 Regex 寫錯時,測試必須直接失敗,不能掃描零個檔案後回報成功。它還不能證明所有高階政策都被找到,至少攔下「檢查集合完全是空的」這種假綠燈。

Use Case Source 引用檢查這次明確轉紅:

src\WorkItems.Api\UseCases\OverdueProcessing\OverdueWorkItemProcessor.cs:
禁止引用 WorkItems.Api.Data

src\WorkItems.Api\UseCases\OverdueProcessing\OverdueWorkItemProcessor.cs:
禁止引用 WorkItemsDbContext

第二階段仍保留相同 DbContext 建構式依賴。違規內容沒有更換,只有 Discovery 開始涵蓋 Processor,因此紅燈可以直接歸因於掃描範圍被補完整。

實驗中還出現另一種假綠燈。Agent 第一次使用 xUnit v3 不支援的舊式 --filter,Runner 回報未知參數,實際執行數量是零。這次結果沒有算進任何綠燈;正式驗證改用 --filter-class,並加入最低測試數檢查。成功摘要必須能回答「到底執行了什麼」。

擴大掃描後架構違規真正轉紅

圖:先找出所有 Production Source,再依 Namespace 篩選;非空檢查防止零檔案假綠燈,非法依賴因此真正轉紅。

第三階段:移除 DbContext 直接依賴,Processor 改用兩個 Use Case Port

規則能抓到違規後,Agent 才移除 Processor 的 DbContext 依賴。協調器只保留兩個 Use Case 真正需要的 Port:

OverdueWorkItemProcessor → IOverdueWorkItemMarker ← EfCoreOverdueWorkItemMarker
OverdueWorkItemProcessor → INotificationOutboxDispatcher ← EfCoreNotificationOutboxDispatcher

兩個 Interface 都由 Use Case 一側定義。Processor 描述自己需要的能力,EF Core Adapter 在外層實作,Program.cs 再把兩邊組裝起來。

public sealed record OverdueProcessingSummary(
    int ProcessedCount,
    int NotificationAttemptCount,
    int NotificationFailureCount,
    Guid[] FailedNotificationWorkItemIds);

public interface IOverdueWorkItemProcessor
{
    Task<OverdueProcessingSummary> ProcessAsync(
        CancellationToken cancellationToken);
}

public sealed class OverdueWorkItemProcessor(
    IOverdueWorkItemMarker overdueWorkItemMarker,
    INotificationOutboxDispatcher notificationOutboxDispatcher)
    : IOverdueWorkItemProcessor
{
    public async Task<OverdueProcessingSummary> ProcessAsync(
        CancellationToken cancellationToken)
    {
        var processedCount = await overdueWorkItemMarker
            .MarkDueWorkItemsOverdueAndCreateNotificationOutboxesAsync(
                cancellationToken);

        var dispatchSummary = await notificationOutboxDispatcher
            .DispatchPendingAsync(cancellationToken);

        return new OverdueProcessingSummary(
            processedCount,
            dispatchSummary.NotificationAttemptCount,
            dispatchSummary.NotificationFailureCount,
            dispatchSummary.FailedNotificationWorkItemIds);
    }
}

OverdueProcessingSummary 也由 Use Case 擁有,只包含 int、Guid[] 等 BCL 型別,沒有攜帶 MVC ActionResult、HTTP Response DTO、EF Entity 或 SDK Response。

HTTP 邊界需要的 ProcessOverdueResponse 仍留在 Contracts,由 Controller 映射:

var processingSummary = await overdueWorkItemProcessor.ProcessAsync(
    cancellationToken);

return Ok(new ProcessOverdueResponse(
    processingSummary.ProcessedCount,
    processingSummary.NotificationAttemptCount,
    processingSummary.NotificationFailureCount,
    processingSummary.FailedNotificationWorkItemIds));

這幾行 Mapping 把責任分開:Use Case 回傳處理摘要,Controller 再轉成 HTTP Contract。未來 JSON 欄位改動時,Processor 不必跟著修改。

透過 Use Case Port 反轉 DbContext 依賴

圖:Processor 只依賴由 Use Case 擁有的 Port,EF Core Adapter 從外側實作契約;修正後的綠燈才有意義。

接受版本用四項檢查保護型別位置、原始碼引用與 Project Reference

接受版本從三個層次建立四項檢查:Use Case 型別所有權、Use Case Source 引用,以及兩條 Project Reference 規則。

規則 保護範圍 能攔下的例子
Use Case 型別所有權 已知協調器、Port、政策與跨界資料 Processor 被放回 API 根目錄或外層 Namespace
Use Case Source 引用 所有 WorkItems.Api.UseCases.* Production Source MVC、EF Core、DbContext、EF Entity、HTTP Contract、SDK 型別滲入
Service Level Project Reference 已獨立的高階政策 Assembly 高階政策反向新增外層 Project 或 Package Reference
模擬 SDK Project Reference 外部 SDK 模擬 Project SDK 反向引用 API Project

Source Rule 的禁止清單包含:

var forbiddenReferences = new[]
{
    "Microsoft.AspNetCore",
    "Microsoft.EntityFrameworkCore",
    "WorkItems.Api.Contracts",
    "WorkItems.Api.Data",
    "WorkItemsDbContext",
    "WorkItems.Api.Models",
    "WorkItems.SimulatedExternalNotificationSdk"
};

這次沒有引進額外 Architecture Testing 套件,因為現有型別與規則不多,Source/Namespace Test 已足以重現真實盲點,也能產生可讀錯誤訊息。

不同檢查能支持的判斷與限制如下:

檢查方式 實際觀察什麼 結果能支持什麼判斷 仍然看不到什麼
Source Scan 原始碼是否出現禁止的 Namespace 或型別名稱 受掃描檔案沒有命中已列禁止字串 C# 語意型別關係、間接依賴與漏列項目
Namespace/Ownership Test 已知高階型別是否位於受保護 Namespace 協調器、Port 與跨界資料沒有離開約定位置 新增但尚未列入已知清單的高階型別
Project Reference Test .csproj 的 Project 與 Package Reference Assembly 沒有直接新增被禁止的編譯依賴 同一 Assembly 內部的錯誤責任與執行時行為
行為與整合測試 輸入、輸出、資料與副作用 外部可觀察行為仍符合契約 原始碼依賴方向是否正確

Source Scan 是字串與位置規則,不是 C# 語意分析器。它可能因註解或同名詞彙誤判,也可能漏掉未加入清單的新型別與間接依賴。即使依賴方向通過,測試仍無法判斷某個 Class 是否承擔錯誤責任,更不能取代行為測試。

這次只把穩定、能明確判定的禁令交給 Architecture Test,其餘仍回到 Repository Context 與 Review。

單一 Assembly 先用 Source/Namespace Test;違規反覆出現再拆 Project

我目前保留單一 API Assembly。高階型別不多,也沒有多人同時修改或相同違規反覆出現的證據;現在拆 Project 會立刻增加 Reference、DI、建置與跨 Project 導覽成本。

因此 API 內先採 Source/Namespace Test;之後高階政策增加、多人維護、清單難以維護或違規反覆發生,再把 API Use Case 升級成獨立 Assembly。

專案情境 建議防線 判斷理由
小型、單一 Assembly、型別不多,邊界仍在演進 Repository Policy+Source/Namespace Test 改動成本低,錯誤訊息可直接對應目前規則
高階政策持續增加、多人維護、違規反覆發生 獨立 Assembly+Project Reference Test 把依賴方向交給 Compiler 與專案結構保護
不同團隊或版本週期,需要獨立發布 Package/明確 Component Boundary 版本與團隊責任已形成真實邊界
需要獨立啟停、故障、資源或部署隔離 Process/Deployment Boundary 只有執行與營運壓力出現時才支付分散式成本

既有 WorkItems.ServiceLevel 已是獨立高階政策 Assembly,因此直接用 Project Reference 規則,檢查它不得新增外層 ProjectReference 或 PackageReference。同一個 Solution 裡,不同責任可以採用不同強度的防線。

「依照 Clean Architecture」資訊不足,AI 還需要型別所有權與停止條件

只在 Prompt 寫「請遵守 Clean Architecture」仍然不夠。User 至少要回答:

  • 哪些 Class 算高階政策?
  • Interface 應該由哪一側擁有?
  • 哪些資料可以跨越邊界?
  • 目前要用 Namespace、Assembly,還是直接拆服務?
  • 哪些外部行為不能因重構而改變?

這些資訊沒有答案時,型別所有權、邊界強度與外部行為都被交給 Agent 推測。輸出可能多拆四個 Project,也可能只換資料夾名稱,實際依賴完全不變。

能重複使用的最低規則,可以先用 AGENTS.md 格式整理:

## Dependency Rule Policy

### 開始修改前,先回報邊界

- 列出本次高階政策、外層技術、執行時控制流與原始碼依賴方向。
- 列出高階政策擁有的 Port、允許跨界的資料,以及禁止進入 Use Case 的型別。
- 若無法判斷某個型別屬於高階政策或外層細節,先停止修改並回報 Repository 證據。

### 依 Repository 情境選擇防線

- Level 1:小型、單一 Assembly、型別不多時,使用 Source/Namespace Test。
- Level 2:高階型別增加、多人維護或相同違規反覆發生時,升級成獨立 Assembly,並加入 Project/Package Reference Test。
- Level 3:出現獨立版本、團隊、故障、資源或部署需求時,才提出 Package、Process 或 Deployment Boundary。
- 回報本次選擇哪一級、依據哪些 Repository 事實,以及停止升級的理由。

### 限制原始碼依賴與跨界資料

- Use Case 只依賴自己擁有的 Port 與資料;不得引用 HTTP DTO、EF Core、DbContext、EF Entity 或第三方 SDK 型別。
- Interface 由需要該能力的高階政策擁有,具體 Adapter 在外層實作,由 Composition Root 完成綁定。
- 跨界資料使用簡單且明確的應用程式型別,不得把外層 Framework Model 當成核心 Contract。
- 不得把 Clean Architecture 四個圓圈直接套成固定 Project 範本。

### 驗證規則真的有執行

- Architecture Test 必須確認受測檔案或型別數量大於零。
- 刻意加入一個受控違規,確認規則能先出現紅燈,再移除違規取得綠燈。
- 失敗訊息必須列出檔案、型別與違反的規則。
- 完成後重跑行為測試與外部 Contract Gate;依賴調整不得改變既有行為。

這份 Policy 沒有把 Namespace Test 寫成唯一答案。Agent 必須依型別數量、違規頻率、團隊與部署壓力選擇 Level,並回報沒有繼續升級的理由。這裡仍是文章中的 Repository Instruction 範例,尚未寫入 API Demo;專案自己的高階型別清單與掃描範圍也不能全部塞進通用 Skill。

CLEAN 原則

E — Explicit Intent and Boundaries 意圖明確:先定義受保護的政策與跨界資料

E — Explicit Intent and Boundaries 意圖明確 先要求 User 定義誰是高階政策、誰擁有 Interface。這次要保護的是 OverdueWorkItemProcessor,Port 由 Use Case 擁有,HTTP DTO 與 EF Entity 都不能成為跨界資料。

Program.cs 則是少數可以同時認識 Interface 與 Adapter 的 Composition Root。

邊界能被命名,Architecture Test 才知道該保護誰。第一階段假綠燈顯示,只列出禁止字串,卻沒有完整受保護集合,規則仍可能形同虛設。

A — Auditable by Evidence 實據可審:綠燈必須附帶掃描範圍與執行證據

這次出現兩種不可信綠燈:既有測試漏掉 Processor,以及測試 Runner 實際執行零項測試。User 不能只採信最後一行成功摘要,還要確認測試找到哪些檔案、執行哪些規則,以及刻意放入違規時是否真的轉紅。

因此正式規約加入三項要求:受測集合不得為空、受控違規必須先產生紅燈、測試工具必須回報符合預期的執行範圍。

N — Non-Surprising Behavior 符合預期:調整依賴方向後,HTTP 與 Outbox 行為仍要保持不變

N — Non-Surprising Behavior 符合預期 要求內部依賴重排後,API 回應、資料 Schema 與通知副作用仍維持原狀。Architecture Test 檢查高階政策有沒有認識外層技術;行為測試則確認相同輸入仍得到相同回應、重試與去重結果。兩種測試都通過,這次修正才算真正綠燈。

架構規則與 API 行為都重新通過,接受版本才算真正綠燈

三個階段完成後,我重新執行所有 Gate,沒有直接採用 Agent 的全綠摘要:

驗證範圍 實驗結果
架構規則 受控違規會轉紅;移除 DbContext 依賴後恢復綠燈
API Contract Route、Status Code 與 Request/Response 保持不變
Outbox 行為 儲存、Retry、Cancellation、lost ACK、並行 Claim 與 Idempotency 語意維持原狀
Smoke 第一次處理逾期項目,第二次執行沒有重複處理或通知
工程 Gate Locked Restore、Release Build、Format Verify 與套件弱點檢查通過

目前能確認的是:受控違規會被規則攔下,移除違規後,既有 API 與 Outbox 行為保持穩定。新的高階政策是否都被列入保護,以及 Interface 是否放錯責任,仍需要設計 Review。

單次實驗顯示規則能抓到這個盲點,是否降低 Token 要等後續變更驗證

這次 Session 同時包含 Repository 探索、製造違規、補出紅燈、修正依賴與完整驗證,不能拿來換算 Dependency Rule 節省多少 Token。目前只確認一件事:補完整 Discovery 後,原本漏過的 DbContext 依賴會讓測試轉紅。

今天接受 Source/Namespace Test,但它必須先證明自己找得到 Use Case

回到標題,Use Case 直接依賴 DbContext 時,Architecture Test 仍然綠燈,原因不是這項依賴突然合理,而是測試把 UseCases 資料夾誤當成完整高階政策集合,根本沒有掃到 Processor。

同一個違規先被漏過,補完整 Discovery 後轉紅,移除依賴才回到綠燈。這次真正修正的不只有 Processor,也包括 Architecture Test 對「誰是高階政策」的認識。

API 內目前先採 Source/Namespace Test;已獨立的 WorkItems.ServiceLevel 繼續由 Project Reference 規則保護。這份防線必須同時具備受保護型別所有權、非空 Discovery、禁止依賴規則,以及能讓受控違規轉紅的證據。

等高階政策增加、多人共同維護或越界反覆發生,再支付拆 Assembly 的成本。無論使用哪一層防線,Agent 建立 Interface 或測試後都不能只回報「全綠」;還要說清楚它找到了誰、檢查了什麼,以及哪些風險仍在掃描範圍之外。

接受版本已建立:

git fetch --tags
git switch --detach day-24-dependency-rule

現在 Dependency Rule 已經有可重跑的檢查與 Repository Policy,但測試只能攔住已定義的依賴,不能替人承擔設計與交付責任。明天就來看另一種更麻煩的情況:功能和測試都通過了,AI 新增的入口卻繞過 Outbox,送出兩次通知。

參考資料


上一篇
Day 23|Controller、EF Core 與第三方 SDK 為什麼不該決定 Use Case?用六角形架構隔離外部細節
下一篇
Day 25|AI 寫完功能、測試全綠,為什麼還會重複通知?
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言